一文带你轻松掌握多种C++编程范式
(给CPP开发者加星标,提升C/C++技能)
来源:神技圈子 https://blog.csdn.net/songguangfan/article/details/110929508
前言
编程范式有多种,主要有结构化的程序设计思想、对于对象的程序设计思想、面向对象的程序设计思想、基于接口的程序设计思想。那么这些范式各是什么意思呢?别着急,我们通过一个加法器的例子来逐一说明。
结构化程序的设计
我们来实现一个加法器,在这个加法器中已经保存了被加数,现在需要传递加数到加法器。如果你是一个C语言开发,第一反应多半是,这个很简单啊,用一个结构体来保存被加数,然后再外带一个加法函数就行了啊
代码中,结构体Augend保存了加法器的被加数,具体而言,就是由iAugend保存,第9至12行给出了加法函数的定义。该函数接收两个参数,一是Augend结构体的指针,二是加数iAddend。
但这个时候老板来了,他对你说,这个加法器要修改一下,现在需要给被加数添加一个权重值,而且以前的加法器要保留,因为还有一部分代码要保留它。没办法,拿人家的手短,吃人家的嘴软,继续当“码农”吧。既然有一部分代码要用到老的加法器,那么老的加法器我们还是保留的,这样一来,就可以按照新的思路来开发新的加法器了,具体的方法如下
可以看到代码思路同上一个代码是完全一致的,不同的只是结构体和函数名称。很显然,WeightAugend保存了被加数和权重,而WeightAdd则是带权重的加法函数。好了,现在我们分析一下按照结构化程序设计思想实现的加法器有什么缺陷?学过面向对象的肯定会一口就能说出来,数据和操作这个数据的函数或方法没有封装在一起。确切一点就是,这个加法器没有把被加数、权重以及操作它们的加法运算封装在一起。另外一个缺陷是什么呢?因为引入带权重的加法器之后,需要对部分老代码进行修改,显然没有做到代码封闭,即没有实现这一变化点的封装。
基于对象的程序设计
在对象的世界,任何东西都可以被当成对象。那么按照这个说法,我们需要实现的这个加法器,显然也是个对象了。用过C++的同学第一反应肯定就是编写一个加法器的类,用一个数据成员保存被加数,然后再写一个public的加法方法就好了。一般就写成如下这样
为了防止隐式类型转换在这里面使用了explicit。同样的,故事还没有完,需要实现带权重的加法运算,但是仍用老代码来实现Adder类。没办法了,我们只有再实现一个带权重的加法器。依葫芦画瓢,WeightingAdder类出炉了。类声明如下图:
WeightingAdder类提供了两个数据成员以分别保存被加数和权重,并提供了带权重的Add方法。现在我们来比较一下基于对象方法和结构化的差异,那就是封装,数据和操作这个数据的方法被封装在了一起。那么在这里,便是被加数或权重、加法运算或带权重的加法运算,被封装在了类Adder或WeightingAdder中,只要实例化一个对象来,你就能使用其加法方法。
一个实际项目中,往往都会有很多的.h文件和.c文件来有效组织代码。如果按照这个思路,通常我们会把结构体Augend的定义放在一个.h文件中,而Add函数的定义和声明,则分别放在了一个.c和.h文件中。这会给加法器的使用者和实现着,带来一点小麻烦。什么麻烦呢?加法者的使用者,需要不停地查看Augend的头文件和Add函数的头文件,因为他需要使用结构体的成员,调用Add函数;而实现者,也需要不断地查看Augend头文件,如果结构体成员、操作它的函数比较多的话,那这个麻烦比较大。怎么办呢?很自然地想法就是把Augend结构体和Add函数放在一起好了。这样管理也比较方便。
上图代码中,使用结构体Adder代表加法器。它包含了两个字段,一是被加数iAugend,另一个则是函数指针。该函数指针需要指向实际的加法运算函数。显然这样一来,就把结构化程序中的结构Augend和Add函数放在了一起。那么这个加法器的使用如下。
在该代码中,首先24行实例化了adder出来。然后。将Add函数的地址,赋予了pFuncAdd字段,并进行了被加数的初始化操作。而地29行,则通过pFuncAdd字段调用了加法函数。其实就已经有点像成员函数调用了。adder就是对象,pFuncAdd就是成员函数了。但是随着这种组织结构体和它的操作函数的方式的广泛应用,很快就会发现一个问题。太麻烦了!只要有一个函数指针字段,通常就得定义一个函数指针类型,就得在结构体的每一个实例创建后完成函数指针的赋值操作,即绑定某个具体的函数。不仅太麻烦还容易出错,怎么办呢?交给编译器算了,把函数的定义或声明都放在结构体里面,编译器自己解析。结构体实例创建后,让编译器自动绑定函数。
基于对象的方法,同结构化方法的差异。基于对象的方法能够实现封装,而这正是结构化方法所欠缺的。之前结构化程序设计思想是分析过,当新增带权重的加法器时,破坏了代码的封闭性,即没能封装这一变化点。但是,基于对象的思想也不能封装这一变化点。原本使用的Adder类的老代码,若要改成使用WeightingAdder类,则必须修改对象创建时的类型、参数传递时的类型等。这显然破坏了代码的封闭性。由此可见,基于对象的方法,仅在结构化方法的基础上,弥补了封装的缺陷,但依然遗留了变化点不能很好封装的问题。
面向对象的程序设计
基于对象和面向对象的区别是什么呢?从技术实现角度上来将,很简单,面向对象多了继承和虚函数。而这又有什么好处呢?先上代码
同之前的类相比大致相同,其区别主要是下面几个方面:
Add函数变成了虚函数,这个意图很明显,就是希望派生类重写它
m_iAugend数据成员访问权限变成了protected,这也是保证派生类能访问它
增加了一个虚析构函数。为什么增加呢?因为如果Adder没有虚析构函数,当delete一个类型为Adder的对象指针,但该指针实际指向的其派生类对象时,则派生类的析构函数不会被调用。
面向对象版本的Adder写好了,但是老板说了还需要写带权重的加法运算。不过思路清晰多了,让WeightingAdder从Adder派生吧。代码如下
很简单,主要是重写了虚函数Add,以及引入了权重数据成员。事情看起来已经很完美了,不仅数据和方法封装在一起,而且代码的封闭性也做到了。真正是这样吗?当然不是,请大家继续往下看。
基于接口的程序设计
假设现在需求又改变了,要求普通加法器的被加数必须是非负整数,而带权重的加法器的被加数,可以是非负的整数,也可以是负的整数。现在Adder类的被加数m_iAugend是int类型的,没法保障一定是非负的整数。如果把它改成unsigned int,对带权重的加法器而言,又不能满足其需求。怎么办呢?其实一致一来,都存在一个误区,总是认为应该在普通加法器的基础上扩展带权重的加法器。实际上,这两者除了都是加法器外,大家没有继承关系,都是平等的。所以在这里可以考虑这两个类是兄弟,都是同一个基类加法器,该加法器都提供了加法运算,也就是Add函数。代码如下
实际上,IAdder是一个接口类,它规定了要成一个加法器所需要实现的接口,即纯虚函数Add。显然Adder类和WeightingAdder类都需要从IAdder类派生,并重写Add函数。下图是这两个类的实现
Adder类的被加数m_iAugend是unsigned int类型,满足被加数为负整数的要求,而这并不影响WeightingAdder类的被加数。同面向对象的方法相比,都封装数据和操作数据的函数,其次,两类方法都能封装既有普通加法器,又有带权重加法器这一变化点。简要说明基于接口的方法如何做到这一点的。
void func(IAdder *pAdder)
{
...
int i = pAdder->Add(5);
}
同上一节类似,函数func接受IAdder类型的指针,并它通过Adder方法。因此,无论是想使用Adder类的对象还是WeightingAdder类的对象,都只需要向func函数传递指针即可。显然,func函数不用修改一句代码就能既用Adder类,又使用WeightingAdder类,达到了封装变化点的要求。但是遇到“普通加法器的被加数,必须是非负的整数,而带权重的加法器的被加数可以是非负整数,也就是负整数”这一变化点时,面向对象的方法出问题了,根本原因在于继承。
上图左边的类图是面向对象的类图,可以看到,面向对象中,WeightingAdder类从Adder类继承而来。继承关系是一种强耦合关系。这表现在派生类既耦合于基类的接口,又耦合于基类的实现。而基于接口的设计中,Adder类和WeightingAdder类从父子关系变成了兄弟关系,均从IAdder继承而来。都是继承关系,为什么它能解决面向对象方法所不能解决的问题呢?因为此继承非彼继承。虽然在基于接口的方法中用到了继承,但是不是强耦合关系。Adder类和Weighting类都继承于基类IAdder类,显然它们都会耦合于基类的接口和实现,但问题是IAdder类实质只有一个纯虚函数Add,并没有任何实现细节,甚至连数据成员都没有。因此耦合于其实现的说法是没有意义。所以其耦合度低于面向对象的类结构。这要是基于接口的方法,能封装类型变化点的原因。
基于接口编程的模板实现
前面已经利用虚函数,实现了加法器的基于接口的版本。而在本节中,我们利用模板来实现加法器的这一版本。下面直接上代码
代码中可以看到,同之前接口类相比多了一个template的帽子,变成了一个类模板。嗯,或者是模板类,类模板和模板类的区别在这里强调一下,**模板类实际上是类模板实例化的一个产物。**很显然,这里IAdder是个类模板,只要给它不同的模板参数T,就能实例化若干模板类出来。在IAdder实现中,最关键的一点就是第19行和第20行,将this指针强转成类型T的指针,然后再调用AddImpl函数。那么这个类型T是什么?AddImpl又从何而来呢?请继续往下看代码。
上图中分别给出了普通加法器Adder和带权加法器WeightingAdder的实现;并且在这两个类中,都给出了函数AddImpl。从这两个函数可以看出,它们实现了具体的加法运算。IAdder类模板中的Add函数,调用了类型T的AddImpl函数。看来这个AddImpl函数同Adder或WeightingAdder中的AddImpl函数有点关系。什么关系呢?我们通过模板半数演绎来讲解。
假设按照下面方式使用加法器
Adder add(5);
adder.Add(4);
第一行我们实例化了Adder类的一个对象出来,而Adder类的基类即IAdder< Adder>。此时模板参数T实际上就是Adder。而对象adder调用Add函数时,Add函数的代码相当于
int Add(int iAddend)
{
Adder *pThis = (Adder*)(this);
pThis->AddImpl(iAddend);
可以看出,上述代码中的T被Adder替换掉了,因为此时模板参数T就是Adder。代码中将this指针,强转成Adder类型的指针。那么,这个转换安全吗?当然,当前的对象即adder,其类型是Adder,因此此时的this指针实际指向的就是Adder类的对象,自己转成自己。当然是安全的。这种方法能封装变化点吗?先看个例子
template<typename T>
void f(IAdder<T> *pAdder)
{
std::cout << pAdder->Add(4) << std::endl;
}
当出现需求变化时,例如要增加带权重的加法器时,即可从IAdder中派生一个WeightingAdder类,并让模板T等于WeightingAdder。对于上面的f函数而言,若要使用Adder加法器,给它传递Adder的对象指针即可,同样,若要使用WeightingAdder加法器,给它传递WeightingAdder对象的指针即可。显然,f函数的代码不用做任何修改。因此也实现了变化点。
基于模板这种方法所实现的多态,同之前利用虚函数实现多态的区别,主要在于前者是静态的,由编译器确定。模板是动态的,运行期才能确定。
- EOF -
关于多种C++编程范式,欢迎在评论中和我探讨。觉得文章不错,请点赞和在看支持我继续分享好文。谢谢!
关注『CPP开发者』
看精选C++技术文章 . 加C++开发者专属圈子
↓↓↓
点赞和在看就是最大的支持❤️